Skip to content

feat(server): add evaluateFlags() API for single-call flag evaluation#498

Open
dmarticus wants to merge 2 commits intomainfrom
posthog-code/posthog-server-evaluate-flags-api
Open

feat(server): add evaluateFlags() API for single-call flag evaluation#498
dmarticus wants to merge 2 commits intomainfrom
posthog-code/posthog-server-evaluate-flags-api

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

@dmarticus dmarticus commented Apr 27, 2026

Problem

Phase 1 + Phase 2 of the Server SDK Feature Flag Evaluations RFC for the JVM posthog-server package. Companion to the Node SDK PR (PostHog/posthog-js#3476) and the Python SDK PR (PostHog/posthog-python#539).

Today every flag check on posthog-server fires its own /flags request, and capture(appendFeatureFlags = true) silently fires yet another on every captured event. The flag values on a captured event can diverge from the ones the code actually branched on when person/group properties differ between calls. appendFeatureFlags also attaches every evaluated flag to every event, which bloats properties on high-volume events.

Tracking RFC: requests-for-comments-internal#1020.

Changes

New API (Phase 1)

postHog.evaluateFlags(distinctId, …) returns a PostHogFeatureFlagEvaluations snapshot:

val flags = postHog.evaluateFlags(distinctId, personProperties = mapOf("plan" to "enterprise"))
if (flags.isEnabled("new-dashboard")) {
    renderNewDashboard()
}
postHog.capture(distinctId, "page_viewed", flags = flags)

A single /flags request powers both branching and event enrichment. isEnabled() and getFlag() fire $feature_flag_called events (deduped through the existing per-distinct-id LRU) with the full metadata — $feature_flag_id, $feature_flag_version, $feature_flag_reason, $feature_flag_request_id, $feature_flag_error, plus $feature_flag_definitions_loaded_at for locally-evaluated flags — so experiment exposure tracking keeps working.

Java callers get a PostHogEvaluateFlagsOptions.builder() analogue alongside the Kotlin named-args entry point:

PostHogFeatureFlagEvaluations flags = postHog.evaluateFlags(
    "user-123",
    PostHogEvaluateFlagsOptions.builder()
        .personProperty("plan", "enterprise")
        .flagKeys(List.of("new-dashboard"))
        .build()
);

Two layers of scoping

  • Network-level (flagKeys option): scopes the underlying /flags request itself. Goes into the request body as flag_keys_to_evaluate.

    val flags = postHog.evaluateFlags(distinctId, flagKeys = listOf("new-dashboard", "checkout-flow"))
  • Event-level (filter helpers): narrow which flags get attached to a captured event without re-fetching.

    postHog.capture(distinctId, "page_viewed", flags = flags.onlyAccessed())
    postHog.capture(distinctId, "page_viewed", flags = flags.only(listOf("new-dashboard")))

Both onlyAccessed() and only(...) clone the snapshot with their own accessed set so filtered views don't back-propagate into the parent.

Deprecations (Phase 2)

The legacy single-flag surface keeps working but is now @Deprecated:

  • isFeatureEnabled(...)
  • getFeatureFlag(...)
  • getFeatureFlagPayload(...)
  • getFeatureFlagResult(...)
  • capture(appendFeatureFlags = true) — emits a one-line deprecation log at runtime when truthy (Kotlin can't deprecate a single parameter value at compile time)

Each @Deprecated annotation includes a message pointing at evaluateFlags(...). Phase 3 (removal in next major) ships separately.

New config option

PostHogConfig.featureFlagsLogWarnings (default true) — set to false to silence the warnings emitted by the onlyAccessed() (empty-access fallback) and only(...) (unknown-key drop) filter helpers. Useful for callers who use those helpers conditionally.

Other request-body additions

evaluateFlags(...) also takes disableGeoip = true to forward geoip_disable into the /flags request body, parallel to the option that already exists in posthog-node and posthog-python.

Local evaluation

Transparent. When the poller resolves a flag, the snapshot carries locally_evaluated = true, reason "Evaluated locally", and $feature_flag_definitions_loaded_at is plumbed through, matching what the per-flag local path emits today.

Backwards compatibility

No breaking changes. All existing call paths return the same values they did before. Kotlin callers see a @Deprecated compile-time warning (silenceable with @Suppress("DEPRECATION")); the appendFeatureFlags = true runtime log goes through the standard config logger.

Internals

PostHogStateless.sendFeatureFlagCalled is split into captureFeatureFlagCalledEvent, which is shared between the per-flag accessor path and the new snapshot. Both paths now dedupe identically against the same PostHogFeatureFlagCalledCache LRU. The single-flag path also picks up $feature_flag_id / $feature_flag_version / $feature_flag_reason here — previously only the Android client emitted those, server SDK was missing them.

A small EvaluationsHost interface is what the snapshot calls back into, instead of holding a reference to the full client — keeps the snapshot easy to test in isolation with a fake host.

Response-level errors (errors_while_computing_flags, quota_limited) are propagated into snapshot $feature_flag_called events via EvaluateFlagsResult.responseError, matching the granularity of the per-flag path.

A new getFeatureFlagDetails(...) default method on PostHogFeatureFlagsInterface (returns null by default) lets the server SDK expose the cached FeatureFlag to the per-flag path without changing existing implementations.

Docs: posthog-server/USAGE.md updated with a Kotlin + Java example of the snapshot flow and the flagKeys vs only(...) distinction.

Tests

Two test files cover snapshot semantics and end-to-end flow:

  • PostHogFeatureFlagEvaluationsTest — snapshot accessors, full metadata on captures, filter helpers, onlyAccessed() empty-fallback warning, only(...) unknown-key warning, parent/child filter isolation, blank-distinctId no-op, locally-evaluated tagging, response-error propagation, featureFlagsLogWarnings = false host suppression.
  • PostHogEvaluateFlagsTest — end-to-end via MockWebServer: single /flags round-trip, no events fire before access, dedup across access, getFlagPayload no-event, capture(flags=…) doesn't issue a second request, flag_keys_to_evaluate body forwarding, blank-distinctId short-circuit, local evaluation tagging, quota_limited propagation, appendFeatureFlags = true deprecated path still works end-to-end.

./gradlew :posthog-server:check :posthog:check (tests + lint + apiCheck + animalsniffer) clean.


Created with PostHog Code

Adds a `PostHogFeatureFlagEvaluations` snapshot returned by
`PostHogInterface.evaluateFlags(distinctId)`. The snapshot exposes
`isEnabled`/`getFlag`/`getFlagPayload` plus `onlyAccessed()` and
`only(keys)` filters; accessor calls fire deduped `$feature_flag_called`
events with `$feature_flag_id`/`$feature_flag_version`/`$feature_flag_reason`
metadata, an empty-distinctId snapshot short-circuits all events, and
filtered clones keep their own access set.

`capture()` gains a `flags` parameter that takes a snapshot and attaches
`$feature/<key>` and `$active_feature_flags` without making a second
`/flags` request. The dedup helper on `PostHogStateless` is split into
`captureFeatureFlagCalledEvent` so both the per-flag accessor and the
snapshot share the same per-distinct-id LRU. Locally-evaluated flags
carry a "local_evaluation" reason and the snapshot stamps
`locally_evaluated=true` plus `$feature_flag_definitions_loaded_at` to
match posthog-node and posthog-python.

Adds `featureFlagsLogWarnings` config option to silence filter-helper
warnings, threads `flagKeys` and `geoip_disable` into the `/flags`
request body, and ships JUnit coverage for the snapshot, dedup,
empty-distinctId, local-evaluation, and `capture(flags=)` paths.

The existing `appendFeatureFlags = true` capture path is preserved
unchanged; deprecation of the per-flag accessors is Phase 2.

Generated-By: PostHog Code
Task-Id: 87de4c67-f607-4432-b8ee-3c059e368f81
@dmarticus dmarticus force-pushed the posthog-code/posthog-server-evaluate-flags-api branch from 91d0f41 to f60ec17 Compare April 27, 2026 20:14
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

posthog-android Compliance Report

Date: 2026-04-29 21:16:23 UTC
Duration: 386ms

⚠️ Some Tests Failed

0/1 tests passed, 1 failed


Feature_Flags Tests

⚠️ 0/1 tests passed, 1 failed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 238ms

Failures

request_payload.request_with_person_properties_device_id

404, message='Not Found', url='http://sdk-adapter:8080/get_feature_flag'

@dmarticus dmarticus marked this pull request as ready for review April 27, 2026 22:12
@dmarticus dmarticus requested a review from a team as a code owner April 27, 2026 22:12
Phase 1 (already on this branch): `evaluateFlags(distinctId)` snapshot,
`capture(flags = …)`, dedup helper extraction, full metadata on
`$feature_flag_called`.

Phase 2 (new in this commit):

- `@Deprecated` annotations on `isFeatureEnabled`, `getFeatureFlag`,
  `getFeatureFlagPayload`, and `getFeatureFlagResult` (all overloads on
  both `PostHogInterface` and the `PostHog` server class), with messages
  pointing callers at `evaluateFlags(...)`.
- Runtime deprecation log when `capture(appendFeatureFlags = true)` is
  used — mirrors the Python PR's "only-when-truthy" runtime warning,
  since Kotlin can't deprecate a single parameter value at compile time.
- All legacy paths keep working unchanged; deprecations can be silenced
  with `@Suppress("DEPRECATION")`. Removal is targeted at the next major.

Also: response-level errors (`errors_while_computing_flags`,
`quota_limited`) are now propagated into snapshot
`$feature_flag_called` events as `$feature_flag_error`, matching the
granularity the per-flag accessor path emits.

New tests:
- `responseError` propagates to `$feature_flag_called` (unit + integration).
- `appendFeatureFlags = true` deprecated path still attaches feature
  properties end-to-end.

Generated-By: PostHog Code
Task-Id: 87de4c67-f607-4432-b8ee-3c059e368f81
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant